{
  config,
  lib,
  pkgs,
  ...
}:
let
  jenkinsCfg = config.services.jenkins;
  cfg = config.services.jenkins.jobBuilder;

in
{
  options = {
    services.jenkins.jobBuilder = {
      enable = lib.mkEnableOption ''
        the Jenkins Job Builder (JJB) service. It
        allows defining jobs for Jenkins in a declarative manner.

        Jobs managed through the Jenkins WebUI (or by other means) are left
        unchanged.

        Note that it really is declarative configuration; if you remove a
        previously defined job, the corresponding job directory will be
        deleted.

        Please see the Jenkins Job Builder documentation for more info:
        <https://jenkins-job-builder.readthedocs.io/>
      '';

      accessUser = lib.mkOption {
        default = "admin";
        type = lib.types.str;
        description = ''
          User id in Jenkins used to reload config.
        '';
      };

      accessToken = lib.mkOption {
        default = "";
        type = lib.types.str;
        description = ''
          User token in Jenkins used to reload config.
          WARNING: This token will be world readable in the Nix store. To keep
          it secret, use the {option}`accessTokenFile` option instead.
        '';
      };

      accessTokenFile = lib.mkOption {
        default = "${config.services.jenkins.home}/secrets/initialAdminPassword";
        defaultText = lib.literalExpression ''"''${config.services.jenkins.home}/secrets/initialAdminPassword"'';
        type = lib.types.str;
        example = "/run/keys/jenkins-job-builder-access-token";
        description = ''
          File containing the API token for the {option}`accessUser`
          user.
        '';
      };

      yamlJobs = lib.mkOption {
        default = "";
        type = lib.types.lines;
        example = ''
          - job:
              name: jenkins-job-test-1
              builders:
                - shell: echo 'Hello world!'
        '';
        description = ''
          Job descriptions for Jenkins Job Builder in YAML format.
        '';
      };

      jsonJobs = lib.mkOption {
        default = [ ];
        type = lib.types.listOf lib.types.str;
        example = lib.literalExpression ''
          [
            '''
              [ { "job":
                  { "name": "jenkins-job-test-2",
                    "builders": [ "shell": "echo 'Hello world!'" ]
                  }
                }
              ]
            '''
          ]
        '';
        description = ''
          Job descriptions for Jenkins Job Builder in JSON format.
        '';
      };

      nixJobs = lib.mkOption {
        default = [ ];
        type = lib.types.listOf lib.types.attrs;
        example = lib.literalExpression ''
          [ { job =
              { name = "jenkins-job-test-3";
                builders = [
                  { shell = "echo 'Hello world!'"; }
                ];
              };
            }
          ]
        '';
        description = ''
          Job descriptions for Jenkins Job Builder in Nix format.

          This is a trivial wrapper around jsonJobs, using builtins.toJSON
          behind the scene.
        '';
      };
    };
  };

  config = lib.mkIf (jenkinsCfg.enable && cfg.enable) {
    assertions = [
      {
        assertion =
          if cfg.accessUser != "" then
            (cfg.accessToken != "" && cfg.accessTokenFile == "")
            || (cfg.accessToken == "" && cfg.accessTokenFile != "")
          else
            true;
        message = ''
          One of accessToken and accessTokenFile options must be non-empty
          strings, but not both. Current values:
            services.jenkins.jobBuilder.accessToken = "${cfg.accessToken}"
            services.jenkins.jobBuilder.accessTokenFile = "${cfg.accessTokenFile}"
        '';
      }
    ];

    systemd.services.jenkins-job-builder = {
      description = "Jenkins Job Builder Service";
      # JJB can run either before or after jenkins. We chose after, so we can
      # always use curl to notify (running) jenkins to reload its config.
      after = [ "jenkins.service" ];
      wantedBy = [ "multi-user.target" ];

      path = with pkgs; [
        jenkins-job-builder
        curl
      ];

      # Q: Why manipulate files directly instead of using "jenkins-jobs upload [...]"?
      # A: Because this module is for administering a local jenkins install,
      #    and using local file copy allows us to not worry about
      #    authentication.
      script =
        let
          yamlJobsFile = builtins.toFile "jobs.yaml" cfg.yamlJobs;
          jsonJobsFiles = map (x: (builtins.toFile "jobs.json" x)) (
            cfg.jsonJobs ++ [ (builtins.toJSON cfg.nixJobs) ]
          );
          jobBuilderOutputDir = "/run/jenkins-job-builder/output";
          # Stamp file is placed in $JENKINS_HOME/jobs/$JOB_NAME/ to indicate
          # ownership. Enables tracking and removal of stale jobs.
          ownerStamp = ".config-xml-managed-by-nixos-jenkins-job-builder";
          reloadScript = ''
            echo "Asking Jenkins to reload config"
            curl_opts="--silent --fail --show-error"
            access_token_file=${
              if cfg.accessTokenFile != "" then
                cfg.accessTokenFile
              else
                "$RUNTIME_DIRECTORY/jenkins_access_token.txt"
            }
            if [ "${cfg.accessToken}" != "" ]; then
               (umask 0077; printf "${cfg.accessToken}" >"$access_token_file")
            fi
            jenkins_url="http://${jenkinsCfg.listenAddress}:${toString jenkinsCfg.port}${jenkinsCfg.prefix}"
            auth_file="$RUNTIME_DIRECTORY/jenkins_auth_file.txt"
            trap 'rm -f "$auth_file"' EXIT
            (umask 0077; printf "${cfg.accessUser}:@password_placeholder@" >"$auth_file")
            "${pkgs.replace-secret}/bin/replace-secret" "@password_placeholder@" "$access_token_file" "$auth_file"

            if ! "${pkgs.jenkins}/bin/jenkins-cli" -s "$jenkins_url" -auth "@$auth_file" reload-configuration; then
                echo "error: failed to reload configuration"
                exit 1
            fi
          '';
        in
        ''
          joinByString()
          {
              local separator="$1"
              shift
              local first="$1"
              shift
              printf "%s" "$first" "''${@/#/$separator}"
          }

          # Map a relative directory path in the output from
          # jenkins-job-builder (jobname) to the layout expected by jenkins:
          # each directory level gets prepended "jobs/".
          getJenkinsJobDir()
          {
              IFS='/' read -ra input_dirs <<< "$1"
              printf "jobs/"
              joinByString "/jobs/" "''${input_dirs[@]}"
          }

          # The inverse of getJenkinsJobDir (remove the "jobs/" prefixes)
          getJobname()
          {
              IFS='/' read -ra input_dirs <<< "$1"
              local i=0
              local nelem=''${#input_dirs[@]}
              for e in "''${input_dirs[@]}"; do
                  if [ $((i % 2)) -eq 1 ]; then
                      printf "$e"
                      if [ $i -lt $(( nelem - 1 )) ]; then
                          printf "/"
                      fi
                  fi
                  i=$((i + 1))
              done
          }

          rm -rf ${jobBuilderOutputDir}
          cur_decl_jobs=/run/jenkins-job-builder/declarative-jobs
          rm -f "$cur_decl_jobs"

          # Create / update jobs
          mkdir -p ${jobBuilderOutputDir}
          for inputFile in ${yamlJobsFile} ${lib.concatStringsSep " " jsonJobsFiles}; do
              HOME="${jenkinsCfg.home}" "${pkgs.jenkins-job-builder}/bin/jenkins-jobs" --ignore-cache test --config-xml -o "${jobBuilderOutputDir}" "$inputFile"
          done

          find "${jobBuilderOutputDir}" -type f -name config.xml | while read -r f; do echo "$(dirname "$f")"; done | sort | while read -r dir; do
              jobname="$(realpath --relative-to="${jobBuilderOutputDir}" "$dir")"
              jenkinsjobname=$(getJenkinsJobDir "$jobname")
              jenkinsjobdir="${jenkinsCfg.home}/$jenkinsjobname"
              echo "Creating / updating job \"$jobname\""
              mkdir -p "$jenkinsjobdir"
              touch "$jenkinsjobdir/${ownerStamp}"
              cp "$dir"/config.xml "$jenkinsjobdir/config.xml"
              echo "$jenkinsjobname" >> "$cur_decl_jobs"
          done

          # Remove stale jobs
          find "${jenkinsCfg.home}" -type f -name "${ownerStamp}" | while read -r f; do echo "$(dirname "$f")"; done | sort --reverse | while read -r dir; do
              jenkinsjobname="$(realpath --relative-to="${jenkinsCfg.home}" "$dir")"
              grep --quiet --line-regexp "$jenkinsjobname" "$cur_decl_jobs" 2>/dev/null && continue
              jobname=$(getJobname "$jenkinsjobname")
              echo "Deleting stale job \"$jobname\""
              jobdir="${jenkinsCfg.home}/$jenkinsjobname"
              rm -rf "$jobdir"
          done
        ''
        + (lib.optionalString (cfg.accessUser != "") reloadScript);
      serviceConfig = {
        Type = "oneshot";
        User = jenkinsCfg.user;
        RuntimeDirectory = "jenkins-job-builder";
      };
    };
  };
}
